perf(am): 봉인 조회가 매 append 마다 원장 전체를 파싱했다 — 꼬리만 읽는다 - #4
Merged
Conversation
`_get_last_seal` 은 모든 `record()` 가 지나는 길인데 `_load_entries` 로 원장의
모든 줄을 json 파싱하고 있었다. append 가 O(n) 이고, 원장은 **쓰일수록 느려진다**
— 무결성 도구에서 기록을 남기는 비용이 기록 수에 비례해 오르는 것은 규율을
지키는 쪽에 벌금을 매기는 것과 같다.
실측(가문 원장 3,097줄 / 3.4 MB): 조회 한 번 **50.390 ms → 0.048 ms**.
증가 곡선(임시 원장, append 지연 중앙값): 120줄 0.390 ms → 820줄 3.183 ms
(8.17배, 선형) → 0.113 ms → 0.108 ms (0.96배, 평평).
메모리 캐시는 **일부러 쓰지 않았다.** 이 원장은 다른 프로세스(cron, 형제
에이전트)도 append 한다. 캐시된 head 는 더 이상 마지막이 아닐 수 있고, 그러면
`prev_seal` 이 체인을 포크시킨다. 파일이 계속 유일한 진실이고, 읽는 양만 줄인다.
의미는 그대로다 — 봉인 없는 꼬리 줄과 파싱 안 되는 줄은 건너뛰고(`_load_entries`
의 `{_corrupt}` 자리표와 같은 취급), 봉인이 하나도 없는 원장은 여전히 GENESIS 다.
tests 53/53 (신규 3):
· 꼬리 읽기가 전수 파싱과 **같은 답**을 내는지 — 빈 파일 · 개행 없이 끝나는 파일 ·
봉인 없는 꼬리 · 손상된 마지막/중간 줄 · 읽기 청크보다 긴 줄 · 비ASCII · 없는 파일
· **양성대조**: 마지막 줄만 보는 틀린 구현을 같은 대조에 걸어, 그 대조가 실제로
틀린 판을 잡아내는지 먼저 증명한다(안 잡으면 그 시험은 아무것도 안 재는 것이다)
· append 가 꼬리만 읽는지 — 벽시계가 아니라 **파일 핸들이 내준 바이트 수**로 잰다.
🔴 이 시험의 첫 판은 `read()` 만 세어 옛 구현(`for line in f`)을 **통과시켰다**.
줄 순회까지 세도록 고친 뒤에야 옛 판을 잡는다(2,004,200 / 2,004,240 바이트).
Co-authored-by: Mother Seara <seara@bhyi4.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
바로 앞 커밋의 후속이자 그 커밋이 만든 결함의 수리다. [거울/트리아지] 가 등급 심사에서 *"디프가 파일 I/O·인코딩인데 이 레포는 Windows CI 가 0"* 이라고 짚었고, 그 자리를 실제로 재 보니 결함이 있었다. ## 무엇이 깨졌나 바이트로 읽기 시작하면서 **텍스트 모드의 유니버설 개행 번역을 잃었다.** `\n` 으로만 잘라서 `\r` 단독 개행 원장이 **한 줄**로 파싱됐고, 조회가 `GENESIS` 를 답했다. 그 답으로 append 하면 **살아 있는 체인 한가운데에 두 번째 genesis 항목**을 쓴다. 이 도구에서 낼 수 있는 최악의 실패다 — 조용하고, 체인을 가른다. ``` CR 단독 개행 · 3항목 원장 수리 전(텍스트 모드) → cccccccc… (마지막 봉인) 꼬리 읽기 첫 판 → GENESIS 🔴 이번 수리 후 → cccccccc… ✅ ``` ## 수리 버퍼를 자르기 전에 `\r\n` · `\r` 을 `\n` 으로 정규화한다. JSON 문자열 안의 날 CR/LF 은 애초에 유효한 JSON 이 아니므로(제어문자는 `\r` 두 글자로 이스케이프된다) 안전하다. CRLF · CR · LF · 섞인 것 넷 다 텍스트 모드 판과 같은 답을 낸다. ## 못 보던 자리를 계기에 넣는다 - 동치 시험 케이스에 **CRLF · CR 단독 · 섞인 개행** 3종 추가(12 → 15). 대조군(`\n` 만 자르는 판)에 걸면 실패한다. - 케이스 파일을 `newline=""` 로 쓴다 — 안 그러면 **Windows 가 모든 `\n` 을 `\r\n` 으로 조용히 바꿔서**, 개행을 시험한다고 이름 붙인 케이스가 개행을 시험하지 않게 된다. - 🔴 **CI 매트릭스에 `windows-latest` 를 넣는다**(잡 3 → 6). 이 패키지는 원장 파일을 읽고 쓴다. 리눅스 전용 매트릭스로는 위 결함을 **원리적으로 볼 수 없었다.** 변조증거 도구가 못 보는 운영체제를 가지고 있을 수는 없다. tests 53/53. Co-authored-by: Mother Seara <seara@bhyi4.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collaborator
Author
[거울/트리아지] 의 B 등급이 옳았다 — 그 자리에 결함이 있었다심사 근거: "봉인 조회는 신뢰의 뿌리인데 디프가 파일 I/O·인코딩이고 이 레포는 Windows CI 가 0". 바이트로 읽기 시작하면서 텍스트 모드의 유니버설 개행 번역을 잃었다. 🔴 이 답으로 append 하면 살아 있는 체인 한가운데에 두 번째 genesis 항목을 쓴다. 무엇을 바꿨나 (
|
이 PR 이 만든 결함이 아니라 **이 PR 이 드러낸 결함**이다. 앞 커밋에서 CI 에 넣은 `windows-latest` 잡이 **첫 실행에서** 잡았다. ## 무엇이었나 판정 줄은 전부 이모지로 시작한다(🪪 ✅ 🔴 ⚪). 그걸 표현 못 하는 콘솔에서 `print` 가 `UnicodeEncodeError` 를 던진다. Windows 기본값이 cp1252 다. ⇒ **멀쩡한 원장에 `am verify` 를 걸면 트레이스백 + 빈 stdout + exit 1.** 변조 판정과 구별이 안 된다. 0.3.0 이 약속한 *"판정이 종료코드에 닿는다"* 가 그 플랫폼에선 **거짓**이었다. 🔴 `record` 는 더 나쁘다: 항목은 **찍기 전에 이미 쓰인다.** 즉 행동은 봉인됐는데 CLI 는 실패를 보고한다 — 실패에 재시도하는 호출자는 **같은 행동을 두 번 기록한다.** (대조군 실측: 원장에 1줄 쓰였고 exit 1.) ## 수리 `_cli()` 진입에서 stdout/stderr 을 UTF-8 + `errors="replace"` 로 재구성한다. 옛 콘솔은 이모지가 `?` 로 강등될 뿐 **판정 글자는 살아남는다.** 찍히지 못한 판정은 아무에게도 닿지 않은 판정이다. ## 시험 (56/56 · 신규 3) `PYTHONIOENCODING=cp1252` 로 몬다 — **OS 가 아니라 인코딩으로**. 한 러너에서만 도는 가드는 대부분의 실행이 안 밟는 가드다. 리눅스에서도 이 3건이 결함을 잡는다 (대조군 = 수리 호출만 뺀 판, 3/3 실패 확인). · 멀쩡한 체인 verify → exit 0 · `OK` 가 stdout 에 남는다 · record → exit 0 · 원장 1줄(중복 없음) · 🔴 **변조 체인 verify → 여전히 exit 1 · `FAIL`** — 수리가 모든 실행을 초록으로 만들어 버리지 않는지 같이 못 박는다 Co-authored-by: Mother Seara <seara@bhyi4.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
앞 커밋의 수리가 **자식의 인코드 실패를 부모의 디코드 실패로 옮겼을 뿐**이었다. Windows 잡이 7 fail → 1 fail 로 줄고 그 하나가 남은 이유다. ## 무엇이 남아 있었나 `_printable_streams()` 가 stdout 을 **UTF-8 로 재구성**했다. 그러자 자식은 UTF-8 바이트를 쓰는데, 그걸 읽는 Windows 호출자는 파이프를 **로케일 인코딩(cp1252)** 으로 디코드한다. 리더 스레드가 `byte 0x81`(증인 줄의 👁 = `F0 9F 91 81`)에서 `UnicodeDecodeError` 로 죽고 `r.stdout` 이 **None** 으로 돌아온다. ``` tests/test_cli_exit_codes.py::test_verify_peer_rewrite_exits_1 > assert "FAIL" in r.stdout and r.returncode == 1 E TypeError: argument of type 'NoneType' is not iterable ``` 🔴 **리눅스에서 그대로 재현했다**(자식 `PYTHONIOENCODING=cp1252` · 부모 `encoding="cp1252"`). 같은 `byte 0x81` 이다. 윈도우는 리더가 별도 스레드라 예외가 `stdout=None` 으로 나타날 뿐이다. ## 수리 **인코딩은 건드리지 않고 `errors="replace"` 만 건다.** 출력을 읽는 쪽은 이미 그 콘솔의 인코딩을 알고 있다 — 그들이 못 견디는 건 코덱 예외지 `?` 가 아니다. 판정 글자는 ASCII 라 어느 쪽이든 온전히 통과한다. ## 시험 (57/57 · 신규 1 · 기존 3 정정) 신규: **자식이 선언한 것과 같은 코덱으로 부모가 파이프를 읽는다** — Windows 하네스를 아무 플랫폼에서나 재현한다. 앞서 쓴 3건도 `read_as="cp1252"` 로 맞췄다(자기가 지정한 인코딩으로 읽는 게 계약이다). 대조군 **둘 다** 잡는다: · 수리 없음 → 4건 실패(원래 크래시) · **옛 수리(UTF-8 강제) → 1건 실패** ← 내가 방금 낼 뻔한 회귀가 정확히 이 시험에 걸린다 Co-authored-by: Mother Seara <seara@bhyi4.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
앞 두 커밋이 `_cli()` 안에서만 콘솔을 고쳤다. `examples/demo_family.py` 는 **그 진입점을 안 지나고 같은 이모지를 직접 찍는다** — 그래서 시험이 57/57 초록인 동안에도 cp1252 에서 계속 죽었다. CI 는 `Run tests` 가 아니라 **`Dogfood` 스텝**에서 빨갛다. 🔴 **이번 왕복의 모양은 인코딩이 아니라 분모다.** `57/57` 은 **pytest 분모**고, CI 초록의 분모는 `pytest` + `Dogfood` + `package` 다. 나는 앞의 하나를 전체로 보고했다. [거울/트리아지] 가 그 자리를 짚었다. ## 수리 예제 상단에서 `am._printable_streams()` 를 부른다(사설 import 는 의도적이다 — 이 패키지 자신의 예제이고 그 헬퍼는 공개 API 가 아니다). ★ **예제는 새 사용자가 처음 실행하는 코드**다. 여기서 죽는 건 시험에서 죽는 것보다 나쁘고, 이건 이 PR 과 무관하게 **원래 그랬다** — 볼 수 있는 러너가 없었을 뿐이다. ## 계기 `Dogfood` 스텝을 **pytest 안으로** 끌어온다: cp1252 콘솔로 예제를 실행해 exit 0 을 확인한다. 한 러너의 한 스텝에만 있는 가드는 **대부분의 실행이 안 밟는다**. 이제 리눅스 pytest 가 잡는다. (설치된 휠 실행처럼 `examples/` 가 없는 배치에선 skip 한다.) 대조군: 예제에서 그 한 줄을 빼면 새 시험이 **실패한다.** tests 58/58. Co-authored-by: Mother Seara <seara@bhyi4.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
무엇이 문제였나
_get_last_seal은 모든record()가 지나는 길인데, 직전 봉인 하나를 얻으려고_load_entries로 원장의 모든 줄을 json 파싱하고 있었다. append 가 O(n) 이므로원장은 쓰일수록 느려진다 — 무결성 도구에서 기록 비용이 기록 수에 비례해 오르는 것은
규율을 지키는 쪽에 벌금을 매기는 것과 같다.
실사용 중에 걸렸다: 가문 원장(3,097줄 / 3.4 MB)에서
am record한 번이 여기서만 50 ms 를 썼다.실측
무엇을 바꿨나
파일 끝에서부터 청크 단위로 읽어 마지막 봉인 줄을 찾는다.
🔴 메모리 캐시는 일부러 쓰지 않았다. 이 원장은 다른 프로세스(cron, 형제 에이전트)도 append 한다.
캐시된 head 는 더 이상 마지막이 아닐 수 있고, 그러면
prev_seal이 체인을 포크시킨다.파일이 계속 유일한 진실이고, 읽는 양만 줄인다.
의미는 그대로다 — 봉인 없는 꼬리 줄과 파싱 안 되는 줄은 건너뛰고(
_load_entries의{_corrupt}자리표와 같은 취급), 봉인이 하나도 없는 원장은 여전히
GENESIS를 답한다.시험 (53/53 · 신규 3)
빈 파일 · 개행 없이 끝나는 파일 · 봉인 없는 꼬리 · 손상된 마지막/중간 줄 ·
읽기 청크보다 긴 줄 · 비ASCII · 없는 파일.
그게 안 잡히면 1번은 "동치"가 아니라 아무것도 안 재는 것이므로, 잡히는지를 먼저 증명한다.
(빠른 기계에서 초록이 되거나 부하 걸린 기계에서 흔들리지 않도록).
🔴 3번의 첫 판은 아무것도 안 재고 있었다
처음 쓴 계수기는
read()만 셌다. 그런데 수리 전 구현은for line in f로 읽는다 —그래서 옛 구현을 대조군으로 돌렸을 때 바이트 0으로 기록되어 그대로 통과했다.
줄 순회까지 세도록 고친 뒤에야 옛 판을 잡는다: 2,004,200 / 2,004,240 바이트를 읽는다고 실패한다.
이 레포가 #62 에서 고친 것과 같은 모양(공허참 통과)이라, 그 경위를 시험 주석에 남겨 두었다.